Repository navigation
Lower mean.dim beyond 4D pooling as an XNNPACK static mean - #24741
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24741
Note: Links to docs will display an error until the docs builds have been completed. ❌ 1 New FailureAs of commit e578110 with merge base bdc7c35 ( NEW FAILURE - The following job has failed:
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
7e905e0 to
99c4063
Compare
|
@claude review effort=max |
|
Claude finished @JakeStevens's task in 36m 9s —— View job Needs Discussion: the 8192-element cut-off in Detailed reviewPR Review: #24741SummaryThis PR adds an Code Quality
Testing
Backward Compatibility
Performance
Specific Comments
RecommendationNeeds Discussion The cut-off is the open design question: either drop it, or justify it with in-model data and make it a partitioner option. The node-type assertions and the tighter LoRA regex should land with this PR. Everything else is minor or can be a follow-up. |
### Ulterior Motive Improving overall BF16 support in light of #24667 ### Summary `ToDimOrderCopyConfig` already lowers fp32 <-> fp16 casts to XNNPACK convert nodes. This allows fp32 <-> bf16 as well, when bf16 is enabled in the partitioner. fp16 <-> bf16 stays unsupported, because XNNPACK has no direct convert between them. bf16 models that upcast a step to fp32 (HF softmax with `dtype=torch.float32`, Llama-style RMSNorm) now stay in one delegate, instead of running the casts in portable kernels between partitions. ExecuTorch's current XNNPACK pin already has the bf16 <-> fp32 convert kernels, so no runtime or pin changes are needed. The config matches `_to_dim_order_copy`, which export emits when dim order is kept, the `to_edge_transform_and_lower` default. Programs exported with `skip_dim_order=True`, as `LLMEdgeManager` and `export_llama` do by default, keep `aten._to_copy`, which no XNNPACK partitioner config matches yet, so their casts (fp16 ones included) still run in portable kernels. Partitioning `_to_copy` dtype casts under the same rules would be a follow-up. ### Benchmarks Measured with `executor_runner` (Release; XNNPACK + portable ops), median of 50 iterations. "Before" exports the same model with the cast lowering disabled. Shapes: - softmax: `softmax(x, -1, dtype=float32).to(bf16)` on `[1,16,S,S]`. - RMSNorm: Llama-style, `[1,T,2048]`. - elementwise: `(x.float()*2+1).to(bf16)` on `[1,128,4096]`. Single thread (Android pinned to the fastest core with `taskset`), ms, before -> after: | model | Apple M4 | iPhone 17 (A19) | Pixel 10 (Tensor G5, X4) | Pixel 6 (Tensor G1, X1; no BF16) | |---|---|---|---|---| | softmax S=128 | 1.172 -> 0.141 (8.3x) | 1.415 -> 0.403 (3.5x) | 1.785 -> 0.701 (2.5x) | 1.760 -> 0.684 (2.6x) | | softmax S=512 | 18.64 -> 2.343 (8.0x) | 20.18 -> 3.246 (6.2x) | 31.16 -> 5.031 (6.2x) | 35.60 -> 6.484 (5.5x) | | RMSNorm T=1 | 0.012 -> 0.002 (5.6x) | 0.021 -> 0.004 (4.7x) | 0.098 -> 0.012 (8.3x) | 0.013 -> 0.004 (3.1x) | | RMSNorm T=128 | 1.266 -> 0.243 (5.2x) | 1.620 -> 0.652 (2.5x) | 2.502 -> 0.820 (3.1x) | 1.731 -> 0.712 (2.4x) | | elementwise | 2.161 -> 0.118 (18.3x) | 2.244 -> 0.418 (5.4x) | 3.754 -> 0.467 (8.0x) | 4.053 -> 0.576 (7.0x) | Default `executor_runner` threads (M4: 16, iPhone: 6, Pixel 10: 6, Pixel 6: 4; unpinned), ms, before -> after: | model | Apple M4 | iPhone 17 (A19) | Pixel 10 (Tensor G5) | Pixel 6 (Tensor G1) | |---|---|---|---|---| | softmax S=128 | 1.199 -> 0.152 (7.9x) | 1.483 -> 0.294 (5.0x) | 3.858 -> 1.020 (3.8x) | 1.980 -> 0.317 (6.2x) | | softmax S=512 | 17.11 -> 0.635 (27x) | 18.98 -> 1.956 (9.7x) | 38.88 -> 5.518 (7.0x) | 25.97 -> 5.074 (5.1x) | | RMSNorm T=1 | 0.010 -> 0.002 (4.8x) | 0.020 -> 0.004 (4.4x) | 0.101 -> 0.101 (1.0x)* | 0.133 -> 0.025 (5.3x) | | RMSNorm T=128 | 1.323 -> 0.347 (3.8x) | 1.616 -> 0.720 (2.2x) | 8.936 -> 1.765 (5.1x) | 2.544 -> 0.642 (4.0x) | | elementwise | 2.154 -> 0.154 (14x) | 2.376 -> 0.355 (6.7x) | 3.241 -> 0.839 (3.9x) | 2.806 -> 0.462 (6.1x) | \* Unpinned, this ~10 us graph runs on a core that doesn't ramp its clock up, so the run hits a ~0.1 ms floor even with 1 thread. Pinned, it's 0.012 ms (table above). RMSNorm still has `mean` outside the delegate (`mean.dim` over the last dim isn't lowered yet; #24741 lowers it). The casts no longer split it further (3 -> 2 delegates). ### Test plan ``` python -m pytest backends/xnnpack/test/ops/test_to_copy_dtype.py backends/xnnpack/test/ops/test_to_copy.py ``` New tests cover: - fp32 I/O around a bf16 linear; - a bf16 upcast; - a bf16 softmax computed in fp32 and cast back, as one delegate; - fp32 -> bf16 rounding matching PyTorch exactly (zero tolerance), including values halfway between two bf16 numbers; - no lowering without `enable_bf16`; - fp16 <-> bf16 not partitioned. The tests that run bf16 in XNNPACK are skipped in fbcode, like the other XNNPACK bf16 runtime tests. The delegation tests fail without the config change.
caf6ec8 to
ac2756c
Compare
## Ulterior Motive Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels. ## Rationale **What**: With `enable_bf16=True`, lower a bf16 `mean.dim` as a convert to fp32, the fp32 `XNNStaticMean` from pytorch#24741, and a convert back to bf16, all inside the delegate. **Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate. ## Details Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new. - The converts are written into the program rather than added by the runtime at load time, as bf16 matmul results are. That keeps the change in Python and matches eager bf16 exactly, but a program exported now keeps the converts if XNNPACK's `xnn_define_static_reduce` later accepts bf16, which is the only thing that rejects a bf16 mean today. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048). - A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` goes through the same converts as any other bf16 mean. - bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741. - Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts. At 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main). - Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries. - An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
## Ulterior Motive Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels. ## Rationale **What**: With `enable_bf16=True`, lower a bf16 `mean.dim` as a convert to fp32, the fp32 `XNNStaticMean` from pytorch#24741, and a convert back to bf16, all inside the delegate. **Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate. ## Details Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new. - The converts are written into the program rather than added by the runtime at load time, as bf16 matmul results are. That keeps the change in Python and matches eager bf16 exactly, but a program exported now keeps the converts if XNNPACK's `xnn_define_static_reduce` later accepts bf16, which is the only thing that rejects a bf16 mean today. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048). - A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` goes through the same converts as any other bf16 mean. - bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741. - Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts. At 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main). - Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries. - An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`. - `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
|
@claude review effort=max |
|
Claude finished @JakeStevens's task in 38m 54s —— View job Request Changes: a quantized channels_last input that feeds a mean other than Global Average Pooling now fails to lower. Two things need discussion: default LLM exports now emit a node that older runtimes reject, with no opt-out, and there is no end-to-end decode number for QK-norm. The rest are test gaps and nits. Detailed reviewPR Review: #24741SummaryThis PR lowers every
I could not run Python in this job, and the XNNPACK submodule was not checked out. Every finding comes from reading the code. Backward Compatibility
Testing
Performance
Specific Comments
RecommendationRequest Changes The quantized channels_last lowering failure needs a fix and a test in this PR, or a test showing it doesn't happen. The opt-out and the end-to-end QK-norm number need discussion before merging but don't block on their own. The test and nit items don't block. |
ac2756c to
9b5b8da
Compare
## Ulterior Motive Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels. ## Rationale **What**: With `enable_bf16=True`, lower a bf16 `mean.dim` as a convert to fp32, the fp32 `XNNStaticMean` from pytorch#24741, and a convert back to bf16, all inside the delegate. **Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate. ## Details Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new. - The converts are written into the program rather than added by the runtime at load time, as bf16 matmul results are. That keeps the change in Python and matches eager bf16 exactly, but a program exported now keeps the converts if XNNPACK's `xnn_define_static_reduce` later accepts bf16, which is the only thing that rejects a bf16 mean today. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048). - A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` goes through the same converts as any other bf16 mean. - bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741. - Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts. At 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main). - Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries. - An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`. - `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
## Ulterior Motive Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels. ## Rationale **What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from pytorch#24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul. **Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate. ## Details Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new. - `defineGenericReduceNode` adds the converts when the serialized input and output are bf16, next to `defineBf16MatMulResult`. The fp32 tensor definition is shared with the matmul path as `defineFp32Like`, and `convertBf16MatMulResult` becomes `convertBf16Result`. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048). - A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it. - bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741. - Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts. At 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main). - Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries. - An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`. - `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
9b5b8da to
aa730da
Compare
Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels. **What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from pytorch#24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul. **Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate. Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new. - `defineGenericReduceNode` adds the converts when the serialized input and output are bf16, next to `defineBf16MatMulResult`. The fp32 tensor definition is shared with the matmul path as `defineFp32Like`, and `convertBf16MatMulResult` becomes `convertBf16Result`. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048). - A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it. - bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741. - Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts. At 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main). - Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries. - An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`. - `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
|
Thanks for the review. Replies to the review above, as of aa730da:
Authored with AI using Claude Code. |
|
Replies to the second review, as of aa730da: Quantized channels_last input. The exact graph (input → q → dq → Opt-out for older runtimes. Not adding one: requiring a runtime with this change is acceptable here. The description names the Tests.
End-to-end QK-norm cost. Qwen3-0.6B 8da4w decode, after over before (above 1 is slower),
The cost is the small multi-head QK-norm means waking the thread pool, in line with your estimate. The mitigation we're taking is in XNNPACK: run small multi-output reductions as a single task, so pthreadpool runs them inline. That fixes it for every reduction of this shape without a cut-off tuned to one thread count. The numbers are in the description. Specific comments.
Authored with AI using Claude Code. |
|
@claude review effort=max |
|
Claude finished @JakeStevens's task in 36m 30s —— View job Approve: I found no blockers in aa730da. What's left is three test gaps, a small cleanup in the layout pass, and a release note that should cover more than LLMs. The test gaps are unpinned layout copies (my trace gives conv → Detailed reviewPR Review: #24741SummaryThis PR lowers every I couldn't run Python in this job, and the XNNPACK submodule isn't checked out. Every finding comes from reading the code, and I traced the transpose counts below by hand without running them. Code Quality
Testing
Backward Compatibility
RecommendationApprove Nothing blocks. The transpose pins, the dtype test and the key check are small test changes, and the release note is a change to the description. The |
## Ulterior Motive Run RMSNorm in LeRobot policies on XNNPACK (pytorch#24667), where `mean.dim` fails to partition with "only supports averaging 4D tensors, got tensor of rank 3". ## Rationale **What**: Add an `XNNStaticMean` node backed by `xnn_define_static_reduce(xnn_reduce_mean)`, and lower every `mean.dim` that is not Global Average Pooling to it: ranks 1 to 6, any dims, and either `keepdim`. **Why**: XNNPACK only lowered a 4D mean over dims [2, 3] with `keepdim=True`. RMSNorm averages the last dim of a 3-D tensor, so its mean stayed on the portable kernel and split the delegate around every norm. ## Details Review order: the serialization schema and `XNNCompiler.cpp`, then `op_mean_dim.py` and `is_global_avg_pool_mean`, then `ChannelsLastTaggedReshapePass`, then `MeanDimConfig`, then the tests. - The 4D, dims [2, 3], `keepdim=True` case still lowers to Global Average Pooling on NHWC. For those programs the `.pte` is byte-identical to main (fp32, fp16, qs8, conv-bn-hardtanh-mean, and a CNN ending in `AdaptiveAvgPool2d(1)`), and the tests check which XNNPACK node each mean serializes to and how many transposes surround it. - Every other `mean.dim` reduces over NCHW dims, so `ChannelsLastTaggedReshapePass` gives it an NCHW input, and the visitor refuses an NHWC one as an internal layout error. After a convolution that adds layout copies inside the one delegate. For conv then `mean(1, keepdim=True)` then conv, the program has 4 transposes, as on main. For `y - y.mean(1, keepdim=True)` after a convolution (the centering in ConvNeXt's LayerNorm2d), the subtraction stays NHWC, so its full-size result is copied back to NCHW at the output: 4 transposes in one delegate, where main has 2 in two delegates around a portable mean. At 96 channels and 56x56 that is 0.75x at 1 thread, 0.85x at 4 and 1.12x at 16, and conv, mean, conv is 0.54x, 0.70x and 0.90x. Remapping the reduction axes to read NHWC directly would remove these copies, also for heads like `x.mean([2, 3])`; that is left for a follow-up. - `is_global_avg_pool_mean` takes the rank from the mean's own output, which with `keepdim=True` equals the input's, because inside `ChannelsLastTaggedReshapePass` the mean's input can be a newly inserted node without `"val"`. - The node uses a shared `_XNNStaticReduce` table, so other XNNPACK reductions can reuse it and `defineGenericReduceNode`. It is appended to `XNodeUnion`, so existing node IDs do not change. Practically every re-exported XNNPACK LLM program contains it, because RMSNorm (`examples/models/llama/norm.py`) and QK-norm lower to it, and those programs fail to load on runtimes built before this change, including released prebuilt runtimes, with `Unhandled node type` in the log. The release note should say so. - `ChannelsLastTaggedReshapePass` now reads the layout of a quantized graph input from the input's own tag. It used to test `meta["val"][0]`, a 3-D slice that is never channels_last, so a channels_last input behind a quantize and dequantize read as NCHW. A mean over NCHW dims of such an input then reduced the wrong axes, and on main a convolution of it got a transpose that fails at runtime. Both now lower correctly. - An empty dim list averages over every dim, as in PyTorch. - Latency against main, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: RMSNorm 1x128x2048 0.48x, 3-D last-dim mean 0.45x, RMSNorm then Linear 0.77x, 4 pre-norm residual MLP blocks 0.99x for decode (1 token) and 0.80x for prefill (64 tokens), conv then mean over channels 0.88x. - XNNPACK splits a reduction with more than one output into threadpool tasks, so a small mean with several outputs can cost more in XNNPACK than on the portable kernel when the pool has many threads. For RMSNorm on 10 shapes from 1x1x128 to 1x32x2048, every shape is 0.43x to 0.97x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.55x (1.5 to 2.7 us more), and the rest 0.44x to 0.93x. At 16 threads those small shapes are 3.7x to 5.7x (21 to 23 us more), and the rest 0.70x to 0.98x. Inside a model with neighbouring delegated ops the cost can remain: decode QK-norm between two linears, 8 heads of 64, goes from 11.8 us to 39.1 us at 16 threads, and 16 heads of 128 from 222 us to 241 us. A mean alone in its delegate pays the same wake-up (a bare 1x128x128 mean is 3.26x at 16 threads and 1.14x at 4), as does any op lowered alone, such as `add` (2.95x at 1x128x128 and 16 threads on main). These means are lowered like every other op the XNNPACK partitioner takes, rather than gated by size, which would be tuned to one thread count and treat static and dynamic exports differently. End to end, Qwen3-0.6B with 8da4w quantization (113 RMSNorm and QK-norm means, 368 delegates on main and 283 here) decodes, after over before in `executor_runner` with the LLM custom ops: on the same Apple M-series CPU 0.98x at 1 thread and 1.11x to 1.12x at the default 16 threads (97 to 87 tokens/s); on an iPhone 17 1.00x at 1 thread and 1.05x at the runner's default (101 to 96 tokens/s); on a Pixel 10 0.98x and 1.00x. The cost is the multi-head QK-norm means. Running small multi-output reductions without waking the pool belongs in XNNPACK and is left for a follow-up. - The LoRA CI scripts check the start of the 8da4w-quantized Qwen3 base model's greedy output with a regex. With RMSNorm and QK-norm now in XNNPACK, its first token can tip from `:\n` to `:` (`assistant: me` instead of `assistant:\n<think>`): unquantized, main and this branch generate the same tokens with logits within 1e-4, but 8da4w re-rounds activations and the two candidates are within about 0.5 logits. The regex accepts exactly those two continuations, and the scripts print it when it does not match. - Tests cover 1-D to 6-D inputs, all dims, `keepdim=False`, fp16 and qs8 over the innermost and a middle dim, dynamic kept and reduced dims over 5 random shapes, RMSNorm, fp32 and qs8 convolutions followed by a mean, a mean over channels whose result meets the convolution output or feeds another convolution (as in ConvNeXt's LayerNorm2d), qs8 channels_last inputs to a mean and to a convolution, the serialized XNNPACK nodes (moved `xnnpack_node_types` from the layout pass tests to the XNNPACK `Tester`), and the layout copies the pass inserts. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py backends/xnnpack/test/passes/test_channels_last_tagged_reshape.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
aa730da to
e578110
Compare
Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels. **What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from pytorch#24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul. **Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate. Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new. - `defineGenericReduceNode` adds the converts when the serialized input and output are bf16, next to `defineBf16MatMulResult`. The fp32 tensor definition is shared with the matmul path as `defineFp32Like`, and `convertBf16MatMulResult` becomes `convertBf16Result`. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048). - A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it. - bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741. - Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts. At 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main). - Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries. - An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`. - `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
## Ulterior Motive Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels. ## Rationale **What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from pytorch#24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul. **Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate. ## Details - `defineGenericReduceNode` adds the converts when a serialized value is bf16, the same way `defineSoftmaxNode` does since pytorch#24805. It reuses that change's helpers: `convertIfDistinct`, and `defineBf16SoftmaxFp32Value`, renamed `defineFp32ValueForBf16` now that reductions use it too. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048). - A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it. - bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to main. - Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts. At 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main). - Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries. - An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`. - `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
## Ulterior Motive Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in #24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels. ## Rationale **What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from #24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul. **Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate. ## Details - `defineGenericReduceNode` adds the converts when a serialized value is bf16, the same way `defineSoftmaxNode` does since #24805. It reuses that change's helpers: `convertIfDistinct`, and `defineBf16SoftmaxFp32Value`, renamed `defineFp32ValueForBf16` now that reductions use it too. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048). - A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it. - bf16 means are lowered at any size, as fp16 and fp32 means are in #24741. fp32 and fp16 programs are byte-identical to main. - Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in #24741. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts. At 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in #24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main). - Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries. - An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which #24745 lowers for programs exported with dim order, and #24768 for programs exported with `skip_dim_order`. - `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
Ulterior Motive
Run RMSNorm in LeRobot policies on XNNPACK (#24667), where
mean.dimfails to partition with "only supports averaging 4D tensors, got tensor of rank 3".Rationale
What: Add an
XNNStaticMeannode backed byxnn_define_static_reduce(xnn_reduce_mean), and lower everymean.dimthat is not Global Average Pooling to it: ranks 1 to 6, any dims, and eitherkeepdim.Why: XNNPACK only lowered a 4D mean over dims [2, 3] with
keepdim=True. RMSNorm averages the last dim of a 3-D tensor, so its mean stayed on the portable kernel and split the delegate around every norm.Details
Review order: the serialization schema and
XNNCompiler.cpp, thenop_mean_dim.pyandis_global_avg_pool_mean, thenChannelsLastTaggedReshapePass, thenMeanDimConfig, then the tests.keepdim=Truecase still lowers to Global Average Pooling on NHWC. For those programs the.pteis byte-identical to main (fp32, fp16, qs8, conv-bn-hardtanh-mean, and a CNN ending inAdaptiveAvgPool2d(1)), and the tests check which XNNPACK node each mean serializes to and how many transposes surround it.mean.dimreduces over NCHW dims, soChannelsLastTaggedReshapePassgives it an NCHW input, and the visitor refuses an NHWC one as an internal layout error. After a convolution that adds layout copies inside the one delegate. For conv thenmean(1, keepdim=True)then conv, the program has 4 transposes, as on main. Fory - y.mean(1, keepdim=True)after a convolution (the centering in ConvNeXt's LayerNorm2d), the subtraction stays NHWC, so its full-size result is copied back to NCHW at the output: 4 transposes in one delegate, where main has 2 in two delegates around a portable mean. At 96 channels and 56x56 that is 0.75x at 1 thread, 0.85x at 4 and 1.12x at 16, and conv, mean, conv is 0.54x, 0.70x and 0.90x. Remapping the reduction axes to read NHWC directly would remove these copies, also for heads likex.mean([2, 3]); that is left for a follow-up.is_global_avg_pool_meantakes the rank from the mean's own output, which withkeepdim=Trueequals the input's, because insideChannelsLastTaggedReshapePassthe mean's input can be a newly inserted node without"val"._XNNStaticReducetable, so other XNNPACK reductions can reuse it anddefineGenericReduceNode. It is appended toXNodeUnion, so existing node IDs do not change. Practically every re-exported XNNPACK LLM program contains it, because RMSNorm (examples/models/llama/norm.py) and QK-norm lower to it, and those programs fail to load on runtimes built before this change, including released prebuilt runtimes, withUnhandled node typein the log. The release note should say so.ChannelsLastTaggedReshapePassnow reads the layout of a quantized graph input from the input's own tag. It used to testmeta["val"][0], a 3-D slice that is never channels_last, so a channels_last input behind a quantize and dequantize read as NCHW. A mean over NCHW dims of such an input then reduced the wrong axes, and on main a convolution of it got a transpose that fails at runtime. Both now lower correctly.add(2.95x at 1x128x128 and 16 threads on main). These means are lowered like every other op the XNNPACK partitioner takes, rather than gated by size, which would be tuned to one thread count and treat static and dynamic exports differently. End to end, Qwen3-0.6B with 8da4w quantization (113 RMSNorm and QK-norm means, 368 delegates on main and 283 here) decodes, after over before inexecutor_runnerwith the LLM custom ops: on the same Apple M-series CPU 0.98x at 1 thread and 1.11x to 1.12x at the default 16 threads (97 to 87 tokens/s); on an iPhone 17 1.00x at 1 thread and 1.05x at the runner's default (101 to 96 tokens/s); on a Pixel 10 0.98x and 1.00x. The cost is the multi-head QK-norm means. Running small multi-output reductions without waking the pool belongs in XNNPACK and is left for a follow-up.:\nto:(assistant: meinstead ofassistant:\n<think>): unquantized, main and this branch generate the same tokens with logits within 1e-4, but 8da4w re-rounds activations and the two candidates are within about 0.5 logits. The regex accepts exactly those two continuations, and the scripts print it when it does not match.keepdim=False, fp16 and qs8 over the innermost and a middle dim, dynamic kept and reduced dims over 5 random shapes, RMSNorm, fp32 and qs8 convolutions followed by a mean, a mean over channels whose result meets the convolution output or feeds another convolution (as in ConvNeXt's LayerNorm2d), qs8 channels_last inputs to a mean and to a convolution, the serialized XNNPACK nodes (movedxnnpack_node_typesfrom the layout pass tests to the XNNPACKTester), and the layout copies the pass inserts.Test plan:
Authored with AI using Claude Code.